// Copyright 2024 syzkaller project authors. All rights reserved.
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.

package main

import (
	"embed"
	"errors"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"net/http"
	"strconv"
	"time"

	"github.com/google/syzkaller/pkg/html/urlutil"
	"github.com/google/syzkaller/syz-cluster/pkg/app"
	"github.com/google/syzkaller/syz-cluster/pkg/blob"
	"github.com/google/syzkaller/syz-cluster/pkg/db"
)

type dashboardHandler struct {
	title           string
	buildRepo       *db.BuildRepository
	seriesRepo      *db.SeriesRepository
	sessionRepo     *db.SessionRepository
	sessionTestRepo *db.SessionTestRepository
	findingRepo     *db.FindingRepository
	statsRepo       *db.StatsRepository
	blobStorage     blob.Storage
	templates       map[string]*template.Template
}

//go:embed templates/*
var templates embed.FS

func newHandler(env *app.AppEnvironment) (*dashboardHandler, error) {
	perFile := map[string]*template.Template{}
	var err error
	for _, name := range []string{"index.html", "series.html", "graphs.html"} {
		perFile[name], err = template.ParseFS(templates,
			"templates/base.html", "templates/templates.html", "templates/"+name)
		if err != nil {
			return nil, err
		}
	}
	return &dashboardHandler{
		title:           env.Config.Name,
		templates:       perFile,
		blobStorage:     env.BlobStorage,
		buildRepo:       db.NewBuildRepository(env.Spanner),
		seriesRepo:      db.NewSeriesRepository(env.Spanner),
		sessionRepo:     db.NewSessionRepository(env.Spanner),
		sessionTestRepo: db.NewSessionTestRepository(env.Spanner),
		findingRepo:     db.NewFindingRepository(env.Spanner),
		statsRepo:       db.NewStatsRepository(env.Spanner),
	}, nil
}

//go:embed static
var staticFs embed.FS

func (h *dashboardHandler) Mux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("/sessions/{id}/log", errToStatus(h.sessionLog))
	mux.HandleFunc("/sessions/{id}/triage_log", errToStatus(h.sessionTriageLog))
	mux.HandleFunc("/sessions/{id}/test_logs", errToStatus(h.sessionTestLog))
	mux.HandleFunc("/sessions/{id}/test_artifacts", errToStatus(h.sessionTestArtifacts))
	mux.HandleFunc("/series/{id}/all_patches", errToStatus(h.allPatches))
	mux.HandleFunc("/series/{id}", errToStatus(h.seriesInfo))
	mux.HandleFunc("/patches/{id}", errToStatus(h.patchContent))
	mux.HandleFunc("/findings/{id}/{key}", errToStatus(h.findingInfo))
	mux.HandleFunc("/builds/{id}/{key}", errToStatus(h.buildInfo))
	mux.HandleFunc("/stats", errToStatus(h.statsPage))
	mux.HandleFunc("/", errToStatus(h.seriesList))
	staticFiles, err := fs.Sub(staticFs, "static")
	if err != nil {
		app.Fatalf("failed to parse templates: %v", err)
	}
	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles))))
	return mux
}

var (
	errNotFound   = errors.New("not found error")
	errBadRequest = errors.New("bad request")
)

type statusOption struct {
	Key   db.SessionStatus
	Value string
}

func (h *dashboardHandler) seriesList(w http.ResponseWriter, r *http.Request) error {
	type MainPageData struct {
		// It's probably not the best idea to expose db entities here,
		// but so far redefining the entity would just duplicate the code.
		List     []*db.SeriesWithSession
		Filter   db.SeriesFilter
		Statuses []statusOption
		// This is very primitive, but better than nothing.
		FilterFormURL string
		PrevPageURL   string
		NextPageURL   string
	}
	const perPage = 100
	offset, err := h.getOffset(r)
	if err != nil {
		return err
	}
	baseURL := r.URL.RequestURI()
	data := MainPageData{
		Filter: db.SeriesFilter{
			Cc:           r.FormValue("cc"),
			Status:       db.SessionStatus(r.FormValue("status")),
			WithFindings: r.FormValue("with_findings") != "",
			Limit:        perPage,
			Offset:       offset,
		},
		// If the filters are changed, the old offset value is irrelevant.
		FilterFormURL: urlutil.DropParam(baseURL, "offset", ""),
		Statuses: []statusOption{
			{db.SessionStatusAny, "any"},
			{db.SessionStatusWaiting, "waiting"},
			{db.SessionStatusInProgress, "in progress"},
			{db.SessionStatusFinished, "finished"},
			{db.SessionStatusSkipped, "skipped"},
		},
	}

	data.List, err = h.seriesRepo.ListLatest(r.Context(), data.Filter, time.Time{})
	if err != nil {
		return fmt.Errorf("failed to query the list: %w", err)
	}
	if data.Filter.Offset > 0 {
		data.PrevPageURL = urlutil.SetParam(baseURL, "offset",
			fmt.Sprintf("%d", max(0, data.Filter.Offset-perPage)))
	}
	// TODO: this is not strictly correct (we also need to check whether there actually more rows).
	// But let's tolerate it for now.
	if len(data.List) == data.Filter.Limit {
		data.NextPageURL = urlutil.SetParam(baseURL, "offset",
			fmt.Sprintf("%d", data.Filter.Offset+len(data.List)))
	}
	return h.renderTemplate(w, "index.html", data)
}

func (h *dashboardHandler) getOffset(r *http.Request) (int, error) {
	val := r.FormValue("offset")
	if val == "" {
		return 0, nil
	}
	i, err := strconv.Atoi(val)
	if err != nil || i < 0 {
		return 0, fmt.Errorf("%w: invalid offset value", errBadRequest)
	}
	return i, nil
}

func (h *dashboardHandler) seriesInfo(w http.ResponseWriter, r *http.Request) error {
	type SessionTest struct {
		*db.FullSessionTest
		Findings []*db.Finding
	}
	type SessionData struct {
		*db.Session
		Tests []SessionTest
	}
	type SeriesData struct {
		*db.Series
		Patches      []*db.Patch
		Sessions     []SessionData
		TotalPatches int
	}
	var data SeriesData
	var err error
	ctx := r.Context()
	data.Series, err = h.seriesRepo.GetByID(ctx, r.PathValue("id"))
	if err != nil {
		return fmt.Errorf("failed to query series: %w", err)
	} else if data.Series == nil {
		return fmt.Errorf("%w: series", errNotFound)
	}
	data.Patches, err = h.seriesRepo.ListPatches(ctx, data.Series)
	if err != nil {
		return fmt.Errorf("failed to query patches: %w", err)
	}
	data.TotalPatches = len(data.Patches)
	sessions, err := h.sessionRepo.ListForSeries(ctx, data.Series)
	if err != nil {
		return fmt.Errorf("failed to query sessions: %w", err)
	}
	for _, session := range sessions {
		rawTests, err := h.sessionTestRepo.BySession(ctx, session.ID)
		if err != nil {
			return fmt.Errorf("failed to query session tests: %w", err)
		}
		findings, err := h.findingRepo.ListForSession(ctx, session.ID, db.NoLimit)
		if err != nil {
			return fmt.Errorf("failed to query session findings: %w", err)
		}
		perName := groupFindings(findings)
		sessionData := SessionData{
			Session: session,
		}
		for _, test := range rawTests {
			sessionData.Tests = append(sessionData.Tests, SessionTest{
				FullSessionTest: test,
				Findings:        perName[test.TestName],
			})
		}
		data.Sessions = append(data.Sessions, sessionData)
	}
	return h.renderTemplate(w, "series.html", data)
}

func (h *dashboardHandler) statsPage(w http.ResponseWriter, r *http.Request) error {
	type StatsPageData struct {
		Processed    []*db.CountPerWeek
		Findings     []*db.CountPerWeek
		Reports      []*db.CountPerWeek
		Delay        []*db.DelayPerWeek
		Distribution []*db.StatusPerWeek
	}
	var data StatsPageData
	var err error
	data.Processed, err = h.statsRepo.ProcessedSeriesPerWeek(r.Context())
	if err != nil {
		return fmt.Errorf("failed to query processed series data: %w", err)
	}
	data.Findings, err = h.statsRepo.FindingsPerWeek(r.Context())
	if err != nil {
		return fmt.Errorf("failed to query findings data: %w", err)
	}
	data.Reports, err = h.statsRepo.ReportsPerWeek(r.Context())
	if err != nil {
		return fmt.Errorf("failed to query reports data: %w", err)
	}
	data.Delay, err = h.statsRepo.DelayPerWeek(r.Context())
	if err != nil {
		return fmt.Errorf("failed to query delay data: %w", err)
	}
	data.Distribution, err = h.statsRepo.SessionStatusPerWeek(r.Context())
	if err != nil {
		return fmt.Errorf("failed to query distribution data: %w", err)
	}
	return h.renderTemplate(w, "graphs.html", data)
}

func groupFindings(findings []*db.Finding) map[string][]*db.Finding {
	ret := map[string][]*db.Finding{}
	for _, finding := range findings {
		ret[finding.TestName] = append(ret[finding.TestName], finding)
	}
	return ret
}

func (h *dashboardHandler) renderTemplate(w http.ResponseWriter, name string, data any) error {
	type page struct {
		Title    string
		Template string
		Data     any
	}
	return h.templates[name].ExecuteTemplate(w, "base.html", page{
		Title:    h.title,
		Template: name,
		Data:     data,
	})
}

// nolint:dupl
func (h *dashboardHandler) sessionLog(w http.ResponseWriter, r *http.Request) error {
	session, err := h.sessionRepo.GetByID(r.Context(), r.PathValue("id"))
	if err != nil {
		return err
	} else if session == nil {
		return fmt.Errorf("%w: session", errNotFound)
	}
	return h.streamBlob(w, session.LogURI)
}

// nolint:dupl
func (h *dashboardHandler) sessionTriageLog(w http.ResponseWriter, r *http.Request) error {
	session, err := h.sessionRepo.GetByID(r.Context(), r.PathValue("id"))
	if err != nil {
		return err
	} else if session == nil {
		return fmt.Errorf("%w: session", errNotFound)
	}
	return h.streamBlob(w, session.TriageLogURI)
}

// nolint:dupl
func (h *dashboardHandler) patchContent(w http.ResponseWriter, r *http.Request) error {
	patch, err := h.seriesRepo.PatchByID(r.Context(), r.PathValue("id"))
	if err != nil {
		return err
	} else if patch == nil {
		return fmt.Errorf("%w: patch", errNotFound)
	}
	return h.streamBlob(w, patch.BodyURI)
}

// nolint:dupl
func (h *dashboardHandler) allPatches(w http.ResponseWriter, r *http.Request) error {
	ctx := r.Context()
	series, err := h.seriesRepo.GetByID(ctx, r.PathValue("id"))
	if err != nil {
		return fmt.Errorf("failed to query series: %w", err)
	} else if series == nil {
		return fmt.Errorf("%w: series", errNotFound)
	}
	patches, err := h.seriesRepo.ListPatches(ctx, series)
	if err != nil {
		return fmt.Errorf("failed to query patches: %w", err)
	}
	for _, patch := range patches {
		err = h.streamBlob(w, patch.BodyURI)
		if err != nil {
			return err
		}
	}
	return nil
}

func (h *dashboardHandler) findingInfo(w http.ResponseWriter, r *http.Request) error {
	finding, err := h.findingRepo.GetByID(r.Context(), r.PathValue("id"))
	if err != nil {
		return err
	} else if finding == nil {
		return fmt.Errorf("%w: finding", errNotFound)
	}
	switch r.PathValue("key") {
	case "report":
		return h.streamBlob(w, finding.ReportURI)
	case "log":
		return h.streamBlob(w, finding.LogURI)
	case "syz_repro":
		opts, err := blob.ReadAllBytes(h.blobStorage, finding.SyzReproOptsURI)
		if err != nil {
			return err
		}
		repro, err := blob.ReadAllBytes(h.blobStorage, finding.SyzReproURI)
		if err != nil {
			return err
		}
		fmt.Fprintf(w, "# %s\n", opts)
		_, err = w.Write(repro)
		return err
	case "c_repro":
		return h.streamBlob(w, finding.CReproURI)
	default:
		return fmt.Errorf("%w: unknown key value", errBadRequest)
	}
}

func (h *dashboardHandler) buildInfo(w http.ResponseWriter, r *http.Request) error {
	build, err := h.buildRepo.GetByID(r.Context(), r.PathValue("id"))
	if err != nil {
		return err
	} else if build == nil {
		return fmt.Errorf("%w: build", errNotFound)
	}
	switch r.PathValue("key") {
	case "log":
		return h.streamBlob(w, build.LogURI)
	case "config":
		return h.streamBlob(w, build.ConfigURI)
	default:
		return fmt.Errorf("%w: unknown key value", errBadRequest)
	}
}

func (h *dashboardHandler) sessionTestLog(w http.ResponseWriter, r *http.Request) error {
	test, err := h.sessionTestRepo.Get(r.Context(), r.PathValue("id"), r.FormValue("name"))
	if err != nil {
		return err
	} else if test == nil {
		return fmt.Errorf("%w: test", errNotFound)
	}
	return h.streamBlob(w, test.LogURI)
}

func (h *dashboardHandler) sessionTestArtifacts(w http.ResponseWriter, r *http.Request) error {
	test, err := h.sessionTestRepo.Get(r.Context(), r.PathValue("id"), r.FormValue("name"))
	if err != nil {
		return err
	} else if test == nil {
		return fmt.Errorf("%w: test", errNotFound)
	}
	filename := fmt.Sprintf("%s_%s.tar.gz", test.SessionID, test.TestName)
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
	w.Header().Set("Content-Type", "application/octet-stream")
	return h.streamBlob(w, test.ArtifactsArchiveURI)
}

func (h *dashboardHandler) streamBlob(w http.ResponseWriter, uri string) error {
	if uri == "" {
		return nil
	}
	reader, err := h.blobStorage.Read(uri)
	if err != nil {
		return err
	}
	defer reader.Close()
	_, err = io.Copy(w, reader)
	return err
}

func errToStatus(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if errors.Is(err, errNotFound) {
			http.Error(w, err.Error(), http.StatusNotFound)
		} else if errors.Is(err, errBadRequest) {
			http.Error(w, err.Error(), http.StatusBadRequest)
		} else if err != nil {
			// TODO: if the error happened in the template, likely we've already printed
			// something to w. Unless we're in streamBlob(), it makes sense to first collect
			// the output in some buffer and only dump it after the exit from the handler.
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	}
}
